UI要素をドラッグする
ドラッグ アンド ドロップは、モバイル アプリの一般的な操作です。 ユーザーが長押しすると (「タッチ&ホールド) ウィジェット上では、その下に別のウィジェットが表示されます。 ユーザーの指でウィジェットをドラッグすると、 最終的な場所を指定して解放します。 このレシピでは、ドラッグ アンド ドロップ インタラクションを構築します ユーザーが食べ物の選択を長押しする場合、 そしてその食べ物を顧客の写真にドラッグします。 それを支払っているのです。
次のアニメーションはアプリの動作を示しています。
このレシピは、事前に作成されたメニュー項目のリストから始まり、 お客さんの列。 最初のステップは長押しを認識することです ドラッグ可能なメニュー項目の写真を表示します。
押してドラッグする
Flutter は次のウィジェットを提供します。LongPressDraggable
開始する必要がある正確な動作を提供します
ドラッグアンドドロップ操作。あLongPressDraggable
ウィジェットは長押しが行われたことを認識し、
ユーザーの指の近くに新しいウィジェットを表示します。
ユーザーがドラッグすると、ウィジェットがユーザーの指に従います。LongPressDraggable
を完全に制御できます
ユーザーがドラッグするウィジェット。
メニューリストの各項目はカスタムで表示されます。MenuListItem
ウィジェット。
MenuListItem(
name: item.name,
price: item.formattedTotalItemPrice,
photoProvider: item.imageProvider,
)
を包みますMenuListItem
ウィジェット付きLongPressDraggable
ウィジェット。
LongPressDraggable<Item>(
data: item,
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: DraggingListItem(
dragKey: _draggableKey,
photoProvider: item.imageProvider,
),
child: MenuListItem(
name: item.name,
price: item.formattedTotalItemPrice,
photoProvider: item.imageProvider,
),
);
この場合、ユーザーが長押しすると、MenuListItem
ウィジェット、LongPressDraggable
ウィジェットにはDraggingListItem
。
これDraggingListItem
の写真を表示します
選択した食品項目(中央下)
ユーザーの指。
のdragAnchorStrategy
プロパティはに設定されていますpointerDragAnchorStrategy
。
このプロパティ値は、LongPressDraggable
をベースにするDraggableListItem
の位置
ユーザーの指。ユーザーが指を動かすと、
のDraggableListItem
それと一緒に動きます。
情報がなければ、ドラッグ アンド ドロップはほとんど役に立ちません。
アイテムを落とすと送信されます。
このために、LongPressDraggable
かかりますdata
パラメータ。
この場合の種類は、data
はItem
、
に関する情報を保持する
ユーザーが押したフードメニュー項目。
のdata
に関連するLongPressDraggable
という特別なウィジェットに送信されますDragTarget
、
ユーザーがドラッグ ジェスチャを放した場所。
次にドロップ動作を実装します。
ドラッグ可能なものをドロップします
ユーザーはドロップできますLongPressDraggable
彼らが選んだ場所はどこでも、
ただし、ドラッグ可能オブジェクトをドロップしても、ドロップされない限り効果はありません
の上にDragTarget
。ユーザーがドラッグ可能アイテムをドロップしたとき
の上部DragTarget
ウィジェット、DragTarget
ウィジェット
ドラッグ可能オブジェクトからのデータを受け入れるか拒否することができます。
このレシピでは、ユーザーはメニュー項目をCustomerCart
メニュー項目をユーザーのカートに追加するウィジェット。
CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
を包みますCustomerCart
ウィジェット付きDragTarget
ウィジェット。
DragTarget<Item>(
builder: (context, candidateItems, rejectedItems) {
return CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
},
onAccept: (item) {
_itemDroppedOnCustomerCart(
item: item,
customer: customer,
);
},
)
のDragTarget
既存のウィジェットを表示し、
ともコーディネートしますd3821411-9fa1-4a7e-aa12-e7a8c0ae2880認めるために
ユーザーがドラッグ可能オブジェクトをその上にドラッグすると、DragTarget
。
のDragTarget
ユーザーが落下したときも認識します
の上にあるドラッグ可能なDragTarget
ウィジェット。
ユーザーがドラッグ可能オブジェクトをドラッグすると、DragTarget
ウィジェット、candidateItems
ユーザーがドラッグしているデータ項目が含まれます。
このドラッグ可能機能を使用すると、ウィジェットの外観を変更できます
ユーザーがその上をドラッグしているときなどです。この場合、
のCustomer
アイテムが上にドラッグされると、ウィジェットが赤くなります。DragTarget
ウィジェット。赤色の外観は、highlighted
内のプロパティCustomerCart
ウィジェット。
ユーザーがドラッグ可能アイテムをドロップすると、DragTarget
ウィジェット、
のonAccept
コールバックが呼び出されます。これはあなたが得るときです
ドロップされたデータを受け入れるかどうかを決定します。
この場合、アイテムは常に受け入れられ、処理されます。
受信したアイテムを検査して、
異なる決断。
ドロップされたアイテムのタイプに注目してください。DragTarget
ドラッグ元の項目のタイプと一致する必要がありますLongPressDraggable
。
型に互換性がない場合は、
のonAccept
メソッドは呼び出されません。
とともにDragTarget
を受け入れるように設定されたウィジェット
必要なデータを1つのパートから送信できるようになりました
ドラッグ アンド ドロップして、UI を別の UI に移動します。
次のステップでは、 ドロップされたメニュー項目で顧客のカートを更新します。
メニュー項目をカートに追加する
各顧客は、Customer
物体、
これは、商品のカートと合計価格を管理します。
class Customer {
Customer({
required this.name,
required this.imageProvider,
List<Item>? items,
}) : items = items ?? [];
final String name;
final ImageProvider imageProvider;
final List<Item> items;
String get formattedTotalItemPrice {
final totalPriceCents =
items.fold<int>(0, (prev, item) => prev + item.totalPriceCents);
return '\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
}
}
のCustomerCart
ウィジェットには顧客の写真が表示されます。
に基づく名前、合計、アイテム数Customer
実例。
メニュー項目がドロップされたときに顧客のカートを更新するには、
ドロップされたアイテムを関連付けられたアイテムに追加しますCustomer
物体。
void _itemDroppedOnCustomerCart({
required Item item,
required Customer customer,
}) {
setState(() {
customer.items.add(item);
});
}
の_itemDroppedOnCustomerCart
メソッドが呼び出されるonAccept()
ユーザーがメニュー項目をドロップしたときCustomerCart
ウィジェット。ドロップしたアイテムをcustomer
オブジェクトと呼び出しsetState()
を引き起こす
レイアウトを更新すると、UI が新しい顧客の情報に合わせて更新されます。
合計金額とアイテム数。
おめでとう!ドラッグアンドドロップ操作がある 顧客のショッピング カートに食品を追加します。
インタラクティブな例
アプリを実行します。
- 食品項目をスクロールします。
- いずれかを押し続けます 指でクリックするか、 ねずみ。
- 持っている間、食品の画像 リストの上に表示されます。
- 画像をドラッグし、いずれかの場所にドロップします。 画面の下の方に人がいます。 画像の下のテキストが次のように更新されます。 その人の料金を反映します。 引き続き食品を追加できます そして料金が蓄積されるのを観察してください。
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleDragAndDrop(),
debugShowCheckedModeBanner: false,
),
);
}
const List<Item> _items = [
Item(
name: 'Spinach Pizza',
totalPriceCents: 1299,
uid: '1',
imageProvider: NetworkImage('https://flutter'
'.dev/docs/cookbook/img-files/effects/split-check/Food1.jpg'),
),
Item(
name: 'Veggie Delight',
totalPriceCents: 799,
uid: '2',
imageProvider: NetworkImage('https://flutter'
'.dev/docs/cookbook/img-files/effects/split-check/Food2.jpg'),
),
Item(
name: 'Chicken Parmesan',
totalPriceCents: 1499,
uid: '3',
imageProvider: NetworkImage('https://flutter'
'.dev/docs/cookbook/img-files/effects/split-check/Food3.jpg'),
),
];
@immutable
class ExampleDragAndDrop extends StatefulWidget {
const ExampleDragAndDrop({super.key});
@override
State<ExampleDragAndDrop> createState() => _ExampleDragAndDropState();
}
class _ExampleDragAndDropState extends State<ExampleDragAndDrop>
with TickerProviderStateMixin {
final List<Customer> _people = [
Customer(
name: 'Makayla',
imageProvider: const NetworkImage('https://flutter'
'.dev/docs/cookbook/img-files/effects/split-check/Avatar1.jpg'),
),
Customer(
name: 'Nathan',
imageProvider: const NetworkImage('https://flutter'
'.dev/docs/cookbook/img-files/effects/split-check/Avatar2.jpg'),
),
Customer(
name: 'Emilio',
imageProvider: const NetworkImage('https://flutter'
'.dev/docs/cookbook/img-files/effects/split-check/Avatar3.jpg'),
),
];
final GlobalKey _draggableKey = GlobalKey();
void _itemDroppedOnCustomerCart({
required Item item,
required Customer customer,
}) {
setState(() {
customer.items.add(item);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _buildAppBar(),
body: _buildContent(),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
iconTheme: const IconThemeData(color: Color(0xFFF64209)),
title: Text(
'Order Food',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontSize: 36,
color: const Color(0xFFF64209),
fontWeight: FontWeight.bold,
),
),
backgroundColor: const Color(0xFFF7F7F7),
elevation: 0,
);
}
Widget _buildContent() {
return Stack(
children: [
SafeArea(
child: Column(
children: [
Expanded(
child: _buildMenuList(),
),
_buildPeopleRow(),
],
),
),
],
);
}
Widget _buildMenuList() {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
separatorBuilder: (context, index) {
return const SizedBox(
height: 12,
);
},
itemBuilder: (context, index) {
final item = _items[index];
return _buildMenuItem(
item: item,
);
},
);
}
Widget _buildMenuItem({
required Item item,
}) {
return LongPressDraggable<Item>(
data: item,
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: DraggingListItem(
dragKey: _draggableKey,
photoProvider: item.imageProvider,
),
child: MenuListItem(
name: item.name,
price: item.formattedTotalItemPrice,
photoProvider: item.imageProvider,
),
);
}
Widget _buildPeopleRow() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 20,
),
child: Row(
children: _people.map(_buildPersonWithDropZone).toList(),
),
);
}
Widget _buildPersonWithDropZone(Customer customer) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
),
child: DragTarget<Item>(
builder: (context, candidateItems, rejectedItems) {
return CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
},
onAccept: (item) {
_itemDroppedOnCustomerCart(
item: item,
customer: customer,
);
},
),
),
);
}
}
class CustomerCart extends StatelessWidget {
const CustomerCart({
super.key,
required this.customer,
this.highlighted = false,
this.hasItems = false,
});
final Customer customer;
final bool highlighted;
final bool hasItems;
@override
Widget build(BuildContext context) {
final textColor = highlighted ? Colors.white : Colors.black;
return Transform.scale(
scale: highlighted ? 1.075 : 1.0,
child: Material(
elevation: highlighted ? 8 : 4,
borderRadius: BorderRadius.circular(22),
color: highlighted ? const Color(0xFFF64209) : Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipOval(
child: SizedBox(
width: 46,
height: 46,
child: Image(
image: customer.imageProvider,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 8),
Text(
customer.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: textColor,
fontWeight:
hasItems ? FontWeight.normal : FontWeight.bold,
),
),
Visibility(
visible: hasItems,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
child: Column(
children: [
const SizedBox(height: 4),
Text(
customer.formattedTotalItemPrice,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${customer.items.length} item${customer.items.length != 1 ? 's' : ''}',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
color: textColor,
fontSize: 12,
),
),
],
),
)
],
),
),
),
);
}
}
class MenuListItem extends StatelessWidget {
const MenuListItem({
super.key,
this.name = '',
this.price = '',
required this.photoProvider,
this.isDepressed = false,
});
final String name;
final String price;
final ImageProvider photoProvider;
final bool isDepressed;
@override
Widget build(BuildContext context) {
return Material(
elevation: 12,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 120,
height: 120,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
height: isDepressed ? 115 : 120,
width: isDepressed ? 115 : 120,
child: Image(
image: photoProvider,
fit: BoxFit.cover,
),
),
),
),
),
const SizedBox(width: 30),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontSize: 18,
),
),
const SizedBox(height: 10),
Text(
price,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
],
),
),
],
),
),
);
}
}
class DraggingListItem extends StatelessWidget {
const DraggingListItem({
super.key,
required this.dragKey,
required this.photoProvider,
});
final GlobalKey dragKey;
final ImageProvider photoProvider;
@override
Widget build(BuildContext context) {
return FractionalTranslation(
translation: const Offset(-0.5, -0.5),
child: ClipRRect(
key: dragKey,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 150,
width: 150,
child: Opacity(
opacity: 0.85,
child: Image(
image: photoProvider,
fit: BoxFit.cover,
),
),
),
),
);
}
}
@immutable
class Item {
const Item({
required this.totalPriceCents,
required this.name,
required this.uid,
required this.imageProvider,
});
final int totalPriceCents;
final String name;
final String uid;
final ImageProvider imageProvider;
String get formattedTotalItemPrice =>
'\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
}
class Customer {
Customer({
required this.name,
required this.imageProvider,
List<Item>? items,
}) : items = items ?? [];
final String name;
final ImageProvider imageProvider;
final List<Item> items;
String get formattedTotalItemPrice {
final totalPriceCents =
items.fold<int>(0, (prev, item) => prev + item.totalPriceCents);
return '\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
}
}